Repository Public
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Page programmatique par municipalité : stats, répartition par type,3// fiches en HTML (maillage vers /estimation/[id]) et municipalités voisines.4import type { Metadata } from "next";5import Link from "next/link";6import { notFound } from "next/navigation";7import {8 munBySlug,9 munStats,10 neighborMunicipalities,11 sampleUnits,12} from "@/lib/municipalites";13import {14 EST_YEAR,15 SITE_URL,16 TYPE_LABELS,17 TYPE_SLUGS,18 moneyFr,19 numFr,20} from "@/lib/seo";21import { VENTE_TYPE_LABELS, dateFrLong, ventesParMunicipalite, vsRolePct } from "@/lib/ventes";2223export const dynamic = "force-dynamic";2425export async function generateMetadata({26 params,27}: {28 params: Promise<{ slug: string }>;29}): Promise<Metadata> {30 const { slug } = await params;31 const mun = munBySlug(slug);32 if (!mun) return { title: "Municipalité introuvable" };33 const s = munStats(mun.nom)!;34 const title = `Valeur des propriétés à ${mun.nom} — médiane ${moneyFr(s.mediane2026)} (${EST_YEAR})`;35 const description = `${numFr(s.n)} propriétés évaluées à ${mun.nom} : valeur médiane estimée ${moneyFr(36 s.mediane202637 )}, croissance ${s.croissancePct == null ? "—" : `${s.croissancePct.toFixed(1).replace(".", ",")} %`} depuis 2021. Maisons, plex, condos, chalets et terrains — estimation gratuite par adresse.`;38 return {39 title,40 description,41 alternates: { canonical: `/municipalite/${slug}` },42 openGraph: { title, description, type: "website" },43 };44}4546export default async function MunicipalitePage({47 params,48}: {49 params: Promise<{ slug: string }>;50}) {51 const { slug } = await params;52 const mun = munBySlug(slug);53 if (!mun) notFound();54 const s = munStats(mun.nom)!;55 const samples = sampleUnits(mun.nom, null, 24);56 const voisines = neighborMunicipalities(mun.nom, 10);57 const ventes = ventesParMunicipalite(mun.nom, 12);58 const url = `${SITE_URL}/municipalite/${slug}`;5960 const jsonLd = {61 "@context": "https://schema.org",62 "@graph": [63 {64 "@type": "City",65 "@id": `${url}#place`,66 name: mun.nom,67 url,68 geo: { "@type": "GeoCoordinates", latitude: mun.lat, longitude: mun.lng },69 containedInPlace: { "@type": "AdministrativeArea", name: "Québec, Canada" },70 },71 {72 "@type": "BreadcrumbList",73 itemListElement: [74 { "@type": "ListItem", position: 1, name: "Accueil", item: SITE_URL },75 { "@type": "ListItem", position: 2, name: "Municipalités", item: `${SITE_URL}/municipalites` },76 { "@type": "ListItem", position: 3, name: mun.nom, item: url },77 ],78 },79 ],80 };8182 return (83 <div className="pb-6 pt-10 sm:pt-14">84 <script85 type="application/ld+json"86 dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}87 />88 <nav className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2">89 <Link href="/" className="hover:underline">90 Accueil91 </Link>{" "}92 /{" "}93 <Link href="/municipalites" className="hover:underline">94 Municipalités95 </Link>{" "}96 / <span className="text-ink">{mun.nom}</span>97 </nav>9899 <section className="mt-6">100 <span className="kicker">Municipalité · Québec</span>101 <h1 className="vp-display mt-3 max-w-4xl text-[clamp(28px,5.4vw,56px)] font-bold uppercase leading-[1.0] tracking-[-0.03em]">102 L'immobilier à <span className="hl">{mun.nom}</span>103 </h1>104 <p className="mt-4 max-w-2xl text-[15.5px] text-ink-2">105 {numFr(s.n)} unités d'évaluation au rôle foncier {EST_YEAR},106 estimées par le modèle Vrai-Prix (modèle hédonique + ventes réelles).107 Cherchez n'importe quelle adresse de {mun.nom} depuis{" "}108 <Link href="/" className="underline">109 l'accueil110 </Link>{" "}111 ou explorez{" "}112 <Link href="/carte" className="underline">113 la carte114 </Link>115 .116 </p>117 </section>118119 {/* ---- Tuiles statistiques ---- */}120 <section className="mt-8 grid grid-cols-2 gap-3 lg:grid-cols-4">121 {[122 ["Propriétés évaluées", numFr(s.n)],123 [`Valeur médiane ${EST_YEAR}`, moneyFr(s.mediane2026)],124 [`Valeur totale ${EST_YEAR}`, moneyFr(s.total2026)],125 [126 "Croissance 2021 → 2026",127 s.croissancePct == null128 ? "—"129 : `${s.croissancePct >= 0 ? "+" : ""}${s.croissancePct.toFixed(1).replace(".", ",")} %`,130 ],131 ].map(([label, val]) => (132 <div key={label} className="vp-card p-4">133 <p className="klabel">{label}</p>134 <p className="vp-display mt-1 break-words text-[clamp(17px,2.2vw,26px)] font-bold tracking-[-0.02em]">135 {val}136 </p>137 </div>138 ))}139 </section>140141 {/* ---- Répartition par type ---- */}142 <section className="mt-12">143 <span className="kicker">Par type de propriété</span>144 <h2 className="vp-display mt-3 text-[clamp(20px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">145 Médianes par type à {mun.nom}146 </h2>147 <div className="mt-5 overflow-x-auto">148 <table className="w-full min-w-[560px] border-collapse text-[14px]">149 <thead>150 <tr className="border-b-2 border-ink text-left">151 <th className="klabel py-2 pr-4">Type</th>152 <th className="klabel py-2 pr-4 text-right">Propriétés</th>153 <th className="klabel py-2 pr-4 text-right">Médiane {EST_YEAR}</th>154 <th className="klabel py-2 text-right">Valeur totale</th>155 </tr>156 </thead>157 <tbody>158 {s.parType.map((t) => (159 <tr key={t.type} className="border-b border-[rgba(20,24,20,0.12)]">160 <td className="py-2.5 pr-4">161 <Link162 href={`/municipalite/${slug}/${TYPE_SLUGS[t.type] ?? t.type}`}163 className="font-medium underline decoration-[rgba(20,24,20,0.3)] underline-offset-2 hover:decoration-ink"164 >165 {TYPE_LABELS[t.type]?.pluriel ?? t.type}166 </Link>167 </td>168 <td className="vp-mono py-2.5 pr-4 text-right">{numFr(t.n)}</td>169 <td className="vp-mono py-2.5 pr-4 text-right">170 {moneyFr(t.mediane2026)}171 </td>172 <td className="vp-mono py-2.5 text-right">{moneyFr(t.total2026)}</td>173 </tr>174 ))}175 </tbody>176 </table>177 </div>178 </section>179180 {/* ---- Propriétés les plus valorisées (liens indexables) ---- */}181 {samples.length > 0 && (182 <section className="mt-12">183 <span className="kicker">Aperçu du registre</span>184 <h2 className="vp-display mt-3 text-[clamp(20px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">185 Propriétés les plus valorisées à {mun.nom}186 </h2>187 <ul className="mt-5 grid gap-x-6 gap-y-1.5 sm:grid-cols-2">188 {samples.map((u) => (189 <li key={u.id_provinc}>190 <Link191 href={`/estimation/${encodeURIComponent(u.id_provinc)}`}192 className="group flex items-baseline justify-between gap-3 border-b border-[rgba(20,24,20,0.12)] py-1.5 text-[13.5px]"193 >194 <span className="truncate group-hover:underline">195 {u.adresse}196 {u.apt ? `, app. ${u.apt}` : ""}197 </span>198 <span className="vp-mono shrink-0 text-[11px] text-ink-2">199 {moneyFr(u.est_2026)}200 </span>201 </Link>202 </li>203 ))}204 </ul>205 <p className="mt-4 text-[13px] text-ink-2">206 Chaque adresse de {mun.nom} a sa fiche : estimation {EST_YEAR},207 fourchette P10-P90, historique 2021-{EST_YEAR} et comparables réels.208 </p>209 </section>210 )}211212 {/* ---- Dernières ventes réelles (registre /ventes) ---- */}213 {ventes.rows.length > 0 && (214 <section className="mt-12">215 <span className="kicker">Ventes réelles</span>216 <h2 className="vp-display mt-3 text-[clamp(20px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">217 Dernières ventes à {mun.nom}218 </h2>219 <p className="mt-2 text-[13.5px] text-ink-2">220 {numFr(ventes.n365)} ventes publiées sur 12 mois221 {ventes.median365 != null ? ` · prix médian ${moneyFr(ventes.median365)}` : ""}.222 Transactions captées chaque jour et appariées au rôle foncier.223 </p>224 <div className="mt-4 overflow-x-auto">225 <table className="w-full min-w-[620px] border-collapse text-[14px]">226 <thead>227 <tr className="border-b-2 border-ink text-left">228 <th className="klabel py-2 pr-4">Date</th>229 <th className="klabel py-2 pr-4">Adresse</th>230 <th className="klabel py-2 pr-4">Type</th>231 <th className="klabel py-2 pr-4 text-right">Prix de vente</th>232 <th className="klabel py-2 text-right">vs rôle</th>233 </tr>234 </thead>235 <tbody>236 {ventes.rows.map((v) => {237 const p = vsRolePct(v.amount, v.valeur_role);238 return (239 <tr key={v.id} className="border-b border-[rgba(20,24,20,0.12)]">240 <td className="vp-mono whitespace-nowrap py-2.5 pr-4 text-[12px] text-ink-2">{dateFrLong(v.date)}</td>241 <td className="py-2.5 pr-4">242 {v.id_provinc ? (243 <Link244 href={`/estimation/${encodeURIComponent(v.id_provinc)}`}245 className="font-medium underline decoration-[rgba(20,24,20,0.3)] underline-offset-2 hover:decoration-ink"246 >247 {v.street ?? "Adresse non publiée"}248 </Link>249 ) : (250 <span className="font-medium">{v.street ?? "Adresse non publiée"}</span>251 )}252 </td>253 <td className="py-2.5 pr-4 text-ink-2">{VENTE_TYPE_LABELS[v.property_type ?? ""] ?? v.property_type ?? "—"}</td>254 <td className="vp-display py-2.5 pr-4 text-right text-[15px] font-bold tracking-[-0.01em]">{moneyFr(v.amount)}</td>255 <td className={`vp-mono py-2.5 text-right ${p != null && p < 0 ? "text-[var(--danger)]" : "text-ink-2"}`}>256 {p == null ? "—" : `${p >= 0 ? "+" : "−"}${Math.abs(p).toFixed(0)} %`}257 </td>258 </tr>259 );260 })}261 </tbody>262 </table>263 </div>264 <p className="mt-4">265 <Link href={`/ventes?q=${encodeURIComponent(mun.nom)}&periode=365`} className="btn btn-ghost">266 Toutes les ventes à {mun.nom} →267 </Link>268 </p>269 </section>270 )}271272 {/* ---- Municipalités voisines ---- */}273 {voisines.length > 0 && (274 <section className="mt-12">275 <span className="kicker">À proximité</span>276 <h2 className="vp-display mt-3 text-[clamp(20px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">277 Municipalités voisines278 </h2>279 <ul className="mt-4 flex flex-wrap gap-2">280 {voisines.map((v) => (281 <li key={v.slug}>282 <Link283 href={`/municipalite/${v.slug}`}284 className="stat-chip transition-colors hover:text-accent-deep"285 >286 {v.nom}287 </Link>288 </li>289 ))}290 </ul>291 </section>292 )}293 </div>294 );295}296